feat(bcode-serve): serve-only binary variant for headless containers - #148
Conversation
The V4 worker spends most of its cold startup waiting for `bcode serve` to become ready: `worker.bcode.listen` is ~1.50s p50 in production, ~54% of the measured worker cold path (ENG-5671). Profiling the compiled binary shows the cost is spread across the whole server import graph rather than concentrated in a few deferrable modules — whichever of `provider`, `session/processor` or `tool/registry` is imported first pays ~200ms and the rest then cost ~0ms. So there is no cheap module to defer, and restructuring 112 imports is not worth the merge friction against upstream. Two levers that do work, applied together: - bytecode compilation: skips JS parse at boot, -33% spawn-to-listening - dropping the 23 unused command modules and the embedded web UI The second matters mostly for size, and the two compound: excluding the unused commands saves 16MB without bytecode but 81MB with it, since dead code also carries compiled bytecode. Measured on darwin-arm64, medians of 7, no web UI: entrypoint plain bytecode opencode index.ts 108MB / 412ms 310MB / 266ms bcode-serve 92MB / 381ms 229MB / 257ms On linux-arm64 (the container target) the variant is 257MB vs 148MB standard — +110MB on a 744MB image, well inside AgentCore's 2GB cap. This lives in its own package so `packages/opencode`, which is forked from upstream and synced regularly, stays byte-for-byte untouched. Everything is additive: the canonical build step still runs first and unmodified, the new step is `continue-on-error` and writes different asset names, so it can never block or clobber a normal release. Cloud consumes the release asset through the install one-liner, so install.sh gains `--variant serve`. `check_version` had to learn about it too — a variant swap keeps the same version string, so the "already installed" short-circuit would otherwise skip a standard -> serve switch. The new build script duplicates the Bun.build config rather than editing the upstream one; to catch drift its smoke test boots `serve` and waits for the listening banner instead of just running `--version`, which a missing build-time `define` would sail straight past.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b8f651ffff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…nstall.sh alone Reverts the `--variant serve` flag added to install.sh in the previous commit and ships a separate `install-bytecode.sh` instead, intended to be hosted at bcode.sh/bytecode alongside (not replacing) bcode.sh/install. install.sh is the published, documented entrypoint that the README and every existing doc point at. Adding a flag to it meant every future change to the variant touched the script humans install with. A second script and a second URL keeps the blast radius at zero: nothing can change what /install serves. The new script takes install.sh's flags, so switching is a one-word change to the URL in a Dockerfile: curl -fsSL https://bcode.sh/bytecode | bash -s -- --no-modify-path --version $V It is deliberately much shorter than install.sh: the variant ships one build per arch (no baseline/AVX2 or musl detection), is linux-only, and targets containers that set PATH in the Dockerfile (no shell-rc editing, no uv hint). `--no-modify-path` is accepted as a no-op so an existing invocation works verbatim. Releases predating this variant do not publish the asset, so a missing asset reports that explicitly rather than letting tar fail on a 404 body. Note: the bcode.sh domain and its path routing are not configured in this repo — only the scripts live here. Mapping /bytecode to this file has to happen wherever /install is currently mapped.
There was a problem hiding this comment.
3 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name=".github/workflows/release.yml">
<violation number="1" location=".github/workflows/release.yml:175">
P1: The serve assets will never be built by this workflow because `packages/bcode-serve/script/build.ts` is not executable, so the direct invocation fails; the continue-on-error setting hides the failure and publishes a release without the opt-in variant. Invoking the script through Bun (or making the file executable) would preserve the intended additive release behavior.</violation>
</file>
<file name="packages/bcode-serve/script/build.ts">
<violation number="1" location="packages/bcode-serve/script/build.ts:59">
P1: Serve installation fails on x64 hosts without AVX2 because this variant has no baseline asset. Add and publish the x64 baseline target (and its musl combination where applicable), or make the installer select a compatible serve asset.</violation>
<violation number="2" location="packages/bcode-serve/script/build.ts:189">
P2: Every successful native smoke test leaves its 30-second timeout armed, delaying build-script exit by up to 30 seconds. Retain the timer handle and clear it in `finally` after either race branch settles.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd05aaec66
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
2 issues found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="install-bytecode.sh">
<violation number="1" location="install-bytecode.sh:134">
P1: Alpine/musl containers can download this Linux asset successfully but the resulting `bcode` cannot start because it is the glibc build; the installer should either detect musl and select a published serve-musl asset or reject musl before downloading.</violation>
<violation number="2" location="install-bytecode.sh:175">
P2: A failed `--version` check leaves the previous installation overwritten; validating `${tmp_dir}/${APP}` before `mv` would prevent an incompatible or corrupt download from bricking an existing `bcode`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Repo is public; internal ticket IDs don't belong in it. Benchmark figures belonged in the PR discussion, not inline — they go stale and were restating the rationale at length. Comments now say why something is the way it is and stop there.
…l ordering Four issues from automated review, all confirmed: build.ts was committed 100644 while the canonical build script is 100755, so `./packages/bcode-serve/script/build.ts` exited 126 (permission denied). With continue-on-error on that step, every release would have shipped without the variant and still looked green. Fixed the mode, and invoke via `bun` so a lost executable bit can't silently disable the step again. The smoke test's Promise.race left its 30s timeout armed after the server came up. An armed timer keeps the event loop alive, so a ~2s build took 32s. Clear it in `finally`. The installer requested the glibc asset unconditionally, so an Alpine host downloaded a binary that cannot exec. It now detects musl and selects the matching asset; the release step publishes the musl targets to go with it (the build script already enumerated them). Non-AVX2 x64 has no baseline asset for this variant, so that case is rejected with a pointer to /install rather than installing a binary that SIGILLs. The AVX2 probe only trips when /proc/cpuinfo is readable — unknown is not the same as absent. The `--version` check ran after the binary was already moved into place, so a bad download replaced a working install and then failed. Validate in the temp dir first and only move on success.
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="install-bytecode.sh">
<violation number="1" location="install-bytecode.sh:184">
P2: Install fails on systems with a `noexec` temporary directory even when the configured destination can execute binaries, because `--version` is now run from `${TMPDIR:-/tmp}`. Staging and validating a temporary copy inside the executable install directory would preserve the existing-install safety without imposing this extra mount requirement.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
My previous fix validated the download by running `--version` from the temp dir, which broke installs on hosts that mount /tmp noexec — common hardening, and a regression: before that change the binary was only ever executed from the install dir. Stage inside the install dir instead. That keeps the safety property (a corrupt or wrong-libc download can no longer replace a working bcode) without requiring an exec-capable /tmp, since the install dir has to allow exec anyway. The final step becomes a same-filesystem `mv`, so the swap is atomic and no reader can observe a half-written binary. The staging file is cleaned up on every exit path, and the failure message now names noexec on the install dir as a cause.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c1664b12c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`Checkout` has no `ref:`, so on `workflow_dispatch` the tree is the dispatch ref (usually main) while the upload still targets `inputs.tag`. Publishing from there would put main's code inside that tag's assets. The serve step now compares HEAD against the tag's commit and skips with a visible warning when they differ, rather than uploading mislabelled binaries. The warning matters because the step is continue-on-error and would otherwise skip silently. The canonical build step has the same exposure — this is pre-existing, not introduced here, and fixing it properly means adding `ref:` to `Checkout` for the whole job. That changes the standard release path, which this PR otherwise leaves alone, so it belongs in its own change.
Reworked the variant from scratch and had the result adversarially reviewed.
The headline is a real vulnerability in what this branch already shipped.
Dropping the embedded web UI did not fail closed. Bun leaves the bare
`import("opencode-web-ui.gen.ts")` in server/shared/ui.ts live in the binary and
resolves it at runtime against the server's cwd, so a file planted at
./node_modules/opencode-web-ui.gen.ts executed in-process, and its default
export was used as a path map that serveUIEffect read and returned over HTTP.
PUBLIC_UI_PATHS lets /site.webmanifest and the manifest icons skip auth, so this
was reachable unauthenticated. Reproduced end to end: GET /site.webmanifest
returned /etc/hosts with HTTP 200 while OPENCODE_SERVER_PASSWORD was set. Fixed
by embedding an empty stub, which resolves the specifier hermetically; the same
request now 404s and the planted module never loads. The standard binary was
never affected — it embeds the real bundle.
Installer, all reproduced before fixing:
- a bare VERSION env var was honoured, so a common Dockerfile ARG could
silently install a different build; only BCODE_VERSION is read now
- musl detection could not fire under pipefail, so non-Alpine musl hosts
silently took the glibc asset
- the binary's own stderr was discarded, so a libc mismatch reported "noexec"
as the likely cause instead of the missing loader
- unset HOME aborted with a raw unbound-variable error
- EXIT-only trap leaked a staged file and temp dir on INT/TERM/HUP under
ash/dash; TERM is what docker build cancellation sends
- the AVX2 guard tripped on cpuinfo with no flags line (qemu-emulated amd64
on an arm64 host), contradicting its own "unknown is not absent" intent
- installing over a directory named bcode reported success and exit 0
- empty and flag-shaped option values were accepted
- the curl/tar preflight was missing, so a missing tar exited 127
Also rewritten as POSIX sh: Alpine is a supported target and ships no bash.
Build script:
- the smoke test's timeout was not a deadline. It killed the child, which does
not end the read loop if a descendant holds stdout, so a failing smoke test
could hang the build; it now races a rejecting timer and SIGKILLs
- musl detection missed Bun's `-musl-baseline` spelling
- assert the version define landed: every define is typeof-guarded upstream,
so a dropped one degrades silently and the banner alone would not notice
- dropped three defines proven inert for this graph (OTUI_TREE_SITTER_WORKER_PATH,
OPENCODE_WORKER_PATH, OPENTUI_LIBC) after byte-identical-output checks
- targets are plain strings now instead of an object matrix
Left alone deliberately: the entrypoint's global options and lifecycle wiring.
Trimming them looked attractive but every candidate failed review — --pure and
the AGENT/OPENCODE env vars have out-of-repo consumers, and Heap.start() is the
only eager path that initialises Global, so removing it would change init
ordering rather than just dropping a diagnostic.
Issue for this PR
Closes #
Type of change
What does this PR do?
Adds a second bcode binary variant, built for headless containers, that starts noticeably faster than the standard one. Nothing that currently ships changes.
A container running
bcode servespends most of its cold start waiting for the server to be ready. Profiling the compiled binary shows the cost is spread evenly across the server import graph rather than concentrated anywhere deferrable — whichever ofprovider/provider,session/processorortool/registrygets imported first pays ~200ms and the others then cost ~0ms, because they all pull one shared core. So there's no cheap module to lazy-load, and restructuring 112 imports isn't worth the merge friction against upstream.Two things do work, and they compound:
Together: 445ms → 297ms spawn-to-listening on darwin-arm64, medians of 7 interleaved runs. On linux-arm64 the variant is 257MB vs 148MB standard — +110MB on a 744MB image, well inside the 2GB cap.
Everything here is additive
packages/opencodeis byte-for-byte untouched. That tree is forked from upstream and synced regularly, so the new entrypoint and build live in their own package rather than as flags on upstream'sscript/build.ts.install.shis byte-for-byte untouched. It's the published path the README points at. The variant gets its own script,install-bytecode.sh, for a second URL (bcode.sh/bytecode).continue-on-error, and writes different asset names, so it can't block or clobber a normal release.Cloud switches with a one-word change to the URL — the new script takes the same flags:
Before this is usable
install-bytecode.shexists onmain.bcode-linux-*-serve.tar.gz— the workflow step that builds it only lands with this PR, so the asset first appears on a tag cut after the merge. Pinning an older version will fail; the script reports that case explicitly.bcode.sh/bytecode→install-bytecode.sh. The domain and its path routing aren't configured in this repo — only the scripts live here — so this has to happen wherever/installis mapped today.To test before any of that, build the binary directly with
bun run --cwd packages/bcode-serve script/build.ts --targets linux-arm64andCOPYit into the image.Reviewer notes
Bun.buildconfig rather than editing upstream's. To catch drift, its smoke test bootsserveand waits for the listening banner instead of running--version— a missing build-timedefinewould sail straight past--versionand only fail in production.install-bytecode.shis deliberately much shorter thaninstall.sh: one build per arch (no baseline/AVX2 or musl detection), linux-only, and containers set PATH in the Dockerfile (no shell-rc editing).--no-modify-pathis a no-op, accepted so an existing invocation works verbatim.-serverather than-bytecodebecause the user-visible difference is functional (onlybcode serveexists; everything else exits 1), while bytecode is just how it's compiled. Happy to rename if the URL and asset should match.How did you verify your code works?
/session,/doc,/event,/openapi.jsonreturn identical statusesbcode run/bcode tuiexit 1 on the variant rather than silently succeeding--versioncheck, and the installed binary serves. Also verified the missing-asset path reports a clear error, plus the non-linux and bad-arch guardsbun run typecheckclean in the new package (added to the root typecheck filter);bash -nclean on the new scriptNot verified end-to-end: the
gh release uploadpath, since running it would write to the real repo. Worth watching the first release — and because the step iscontinue-on-error, a failure there won't turn the job red, so check the asset list in the run summary.Screenshots / recordings
n/a — no UI change.
Checklist